import Vue from "../../utils/vue";
import identity from "../../utils/identity";
import KeyCodes from "../../utils/key-codes";
import looseEqual from "../../utils/loose-equal";
import observeDom from "../../utils/observe-dom";
import stableSort from "../../utils/stable-sort";
import { arrayIncludes, concat } from "../../utils/array";
import { NlyEvent } from "../../utils/nly-event.class";
import { requestAF, selectAll } from "../../utils/dom";
import { isEvent } from "../../utils/inspect";
import { mathMax } from "../../utils/math";
import { toInteger } from "../../utils/number";
import { omit } from "../../utils/object";
import idMixin from "../../mixins/id";
import normalizeSlotMixin from "../../mixins/normalize-slot";
import { NlyLink } from "../link/link";
import { NlyNav, props as NlyNavProps } from "../nav/nav";

// -- Constants --

const navProps = omit(NlyNavProps, ["tabs", "isNavBar", "cardHeader"]);

// -- Utils --

// Filter function to filter out disabled tabs
const notDisabled = tab => !tab.disabled;

// --- Helper components ---

// @vue/component
const BTabButtonHelper = /*#__PURE__*/ Vue.extend({
  name: "BTabButtonHelper",
  inject: {
    nlyaTabls: {
      /* istanbul ignore next */
      default() /* istanbul ignore next */ {
        return {};
      }
    }
  },
  props: {
    // Reference to the child <b-tab> instance
    tab: { default: null },
    tabs: {
      type: Array,
      /* istanbul ignore next */
      default() /* istanbul ignore next */ {
        return [];
      }
    },
    id: { type: String, default: null },
    controls: { type: String, default: null },
    tabIndex: { type: Number, default: null },
    posInSet: { type: Number, default: null },
    setSize: { type: Number, default: null },
    noKeyNav: { type: Boolean, default: false }
  },
  methods: {
    focus() {
      if (this.$refs && this.$refs.link && this.$refs.link.focus) {
        this.$refs.link.focus();
      }
    },
    handleEvt(evt) {
      const stop = () => {
        evt.preventDefault();
        evt.stopPropagation();
      };
      if (this.tab.disabled) {
        /* istanbul ignore next */
        return;
      }
      const type = evt.type;
      const key = evt.keyCode;
      const shift = evt.shiftKey;
      if (type === "click") {
        stop();
        this.$emit("click", evt);
      } else if (type === "keydown" && key === KeyCodes.SPACE) {
        stop();
        this.$emit("click", evt);
      } else if (type === "keydown" && !this.noKeyNav) {
        // For keyboard navigation
        if (
          key === KeyCodes.UP ||
          key === KeyCodes.LEFT ||
          key === KeyCodes.HOME
        ) {
          stop();
          if (shift || key === KeyCodes.HOME) {
            this.$emit("first", evt);
          } else {
            this.$emit("prev", evt);
          }
        } else if (
          key === KeyCodes.DOWN ||
          key === KeyCodes.RIGHT ||
          key === KeyCodes.END
        ) {
          stop();
          if (shift || key === KeyCodes.END) {
            this.$emit("last", evt);
          } else {
            this.$emit("next", evt);
          }
        }
      }
    }
  },
  render(h) {
    const link = h(
      NlyLink,
      {
        ref: "link",
        staticClass: "nav-link",
        class: [
          {
            active: this.tab.localActive && !this.tab.disabled,
            disabled: this.tab.disabled
          },
          this.tab.titleLinkClass,
          // Apply <b-tabs> `activeNavItemClass` styles when the tab is active
          this.tab.localActive ? this.nlyaTabls.activeNavItemClass : null
        ],
        props: { disabled: this.tab.disabled },
        attrs: {
          ...this.tab.titleLinkAttributes,
          role: "tab",
          id: this.id,
          // Roving tab index when keynav enabled
          tabindex: this.tabIndex,
          "aria-selected":
            this.tab.localActive && !this.tab.disabled ? "true" : "false",
          "aria-setsize": this.setSize,
          "aria-posinset": this.posInSet,
          "aria-controls": this.controls
        },
        on: {
          click: this.handleEvt,
          keydown: this.handleEvt
        }
      },
      [this.tab.normalizeSlot("title") || this.tab.title]
    );
    return h(
      "li",
      {
        staticClass: "nav-item",
        class: [this.tab.titleItemClass],
        attrs: { role: "presentation" }
      },
      [link]
    );
  }
});

// @vue/component
export const NlyTabs = /*#__PURE__*/ Vue.extend({
  name: "NlyTabs",
  mixins: [idMixin, normalizeSlotMixin],
  provide() {
    return {
      nlyaTabls: this
    };
  },
  model: {
    prop: "value",
    event: "input"
  },
  props: {
    ...navProps,
    tag: {
      type: String,
      default: "div"
    },
    card: {
      type: Boolean,
      default: false
    },
    end: {
      // Synonym for 'bottom'
      type: Boolean,
      default: false
    },
    noFade: {
      type: Boolean,
      default: false
    },
    noNavStyle: {
      type: Boolean,
      default: false
    },
    noKeyNav: {
      type: Boolean,
      default: false
    },
    lazy: {
      // This prop is sniffed by the <b-tab> child
      type: Boolean,
      default: false
    },
    contentClass: {
      type: [String, Array, Object]
      // default: null
    },
    navClass: {
      type: [String, Array, Object]
      // default: null
    },
    navWrapperClass: {
      type: [String, Array, Object]
      // default: null
    },
    activeNavItemClass: {
      // Only applied to the currently active <b-nav-item>
      type: [String, Array, Object]
      // default: null
    },
    activeTabClass: {
      // Only applied to the currently active <b-tab>
      // This prop is sniffed by the <b-tab> child
      type: [String, Array, Object]
      // default: null
    },
    value: {
      // v-model
      type: Number,
      default: null
    }
  },
  data() {
    return {
      // Index of current tab
      currentTab: toInteger(this.value, -1),
      // Array of direct child <b-tab> instances, in DOM order
      tabs: [],
      // Array of child instances registered (for triggering reactive updates)
      registeredTabs: [],
      // Flag to know if we are mounted or not
      isMounted: false
    };
  },
  computed: {
    fade() {
      // This computed prop is sniffed by the tab child
      return !this.noFade;
    },
    localNavClass() {
      const classes = [];
      if (this.card && this.vertical) {
        classes.push("card-header", "h-100", "border-bottom-0", "rounded-0");
      }
      return [...classes, this.navClass];
    }
  },
  watch: {
    currentTab(newVal) {
      let index = -1;
      // Ensure only one tab is active at most
      this.tabs.forEach((tab, idx) => {
        if (newVal === idx && !tab.disabled) {
          tab.localActive = true;
          index = idx;
        } else {
          tab.localActive = false;
        }
      });
      // Update the v-model
      this.$emit("input", index);
    },
    value(newVal, oldVal) {
      if (newVal !== oldVal) {
        newVal = toInteger(newVal, -1);
        oldVal = toInteger(oldVal, 0);
        const tabs = this.tabs;
        if (tabs[newVal] && !tabs[newVal].disabled) {
          this.activateTab(tabs[newVal]);
        } else {
          // Try next or prev tabs
          if (newVal < oldVal) {
            this.previousTab();
          } else {
            this.nextTab();
          }
        }
      }
    },
    registeredTabs() {
      // Each b-tab will register/unregister itself.
      // We use this to detect when tabs are added/removed
      // to trigger the update of the tabs.
      this.$nextTick(() => {
        requestAF(() => {
          this.updateTabs();
        });
      });
    },
    tabs(newVal, oldVal) {
      // If tabs added, removed, or re-ordered, we emit a `changed` event.
      // We use `tab._uid` instead of `tab.safeId()`, as the later is changed
      // in a nextTick if no explicit ID is provided, causing duplicate emits.
      if (
        !looseEqual(
          newVal.map(t => t._uid),
          oldVal.map(t => t._uid)
        )
      ) {
        // In a nextTick to ensure currentTab has been set first.
        this.$nextTick(() => {
          // We emit shallow copies of the new and old arrays of tabs, to
          // prevent users from potentially mutating the internal arrays.
          this.$emit("changed", newVal.slice(), oldVal.slice());
        });
      }
    },
    isMounted(newVal) {
      // Trigger an update after mounted.  Needed for tabs inside lazy modals.
      if (newVal) {
        requestAF(() => {
          this.updateTabs();
        });
      }
      // Enable or disable the observer
      this.setObserver(newVal);
    }
  },
  created() {
    this.currentTab = toInteger(this.value, -1);
    this._nlyaObserver = null;
    // For SSR and to make sure only a single tab is shown on mount
    // We wrap this in a `$nextTick()` to ensure the child tabs have been created
    this.$nextTick(() => {
      this.updateTabs();
    });
  },
  mounted() {
    // Call `updateTabs()` just in case...
    this.updateTabs();
    this.$nextTick(() => {
      // Flag we are now mounted and to switch to DOM for tab probing.
      // As this.$slots.default appears to lie about component instances
      // after b-tabs is destroyed and re-instantiated.
      // And this.$children does not respect DOM order.
      this.isMounted = true;
    });
  },
  /* istanbul ignore next */
  deactivated() /* istanbul ignore next */ {
    this.isMounted = false;
  },
  /* istanbul ignore next */
  activated() /* istanbul ignore next */ {
    this.currentTab = toInteger(this.value, -1);
    this.$nextTick(() => {
      this.updateTabs();
      this.isMounted = true;
    });
  },
  beforeDestroy() {
    this.isMounted = false;
  },
  destroyed() {
    // Ensure no references to child instances exist
    this.tabs = [];
  },
  methods: {
    registerTab(tab) {
      if (!arrayIncludes(this.registeredTabs, tab)) {
        this.registeredTabs.push(tab);
        tab.$once("hook:destroyed", () => {
          this.unregisterTab(tab);
        });
      }
    },
    unregisterTab(tab) {
      this.registeredTabs = this.registeredTabs.slice().filter(t => t !== tab);
    },
    setObserver(on) {
      // DOM observer is needed to detect changes in order of tabs
      if (on) {
        // Make sure no existing observer running
        this.setObserver(false);
        const self = this;
        /* istanbul ignore next: difficult to test mutation observer in JSDOM */
        const handler = () => {
          // We delay the update to ensure that `tab.safeId()` has
          // updated with the final ID value.
          self.$nextTick(() => {
            requestAF(() => {
              self.updateTabs();
            });
          });
        };
        // Watch for changes to <b-tab> sub components
        this._nlyaObserver = observeDom(this.$refs.tabsContainer, handler, {
          childList: true,
          subtree: false,
          attributes: true,
          attributeFilter: ["id"]
        });
      } else {
        if (this._nlyaObserver && this._nlyaObserver.disconnect) {
          this._nlyaObserver.disconnect();
        }
        this._nlyaObserver = null;
      }
    },
    getTabs() {
      const tabs = this.registeredTabs.filter(
        tab => tab.$children.filter(t => t._isTab).length === 0
      );
      // DOM Order of Tabs
      let order = [];
      if (this.isMounted && tabs.length > 0) {
        // We rely on the DOM when mounted to get the 'true' order of the b-tab children.
        // querySelectorAll(...) always returns elements in document order, regardless of
        // order specified in the selector.
        const selector = tabs.map(tab => `#${tab.safeId()}`).join(", ");
        order = selectAll(selector, this.$el)
          .map(el => el.id)
          .filter(identity);
      }
      // Stable sort keeps the original order if not found in the
      // `order` array, which will be an empty array before mount.
      return stableSort(tabs, (a, b) => {
        return order.indexOf(a.safeId()) - order.indexOf(b.safeId());
      });
    },
    // Update list of <b-tab> children
    updateTabs() {
      // Probe tabs
      const tabs = this.getTabs();

      // Find *last* active non-disabled tab in current tabs
      // We trust tab state over currentTab, in case tabs were added/removed/re-ordered
      let tabIndex = tabs.indexOf(
        tabs
          .slice()
          .reverse()
          .find(tab => tab.localActive && !tab.disabled)
      );

      // Else try setting to currentTab
      if (tabIndex < 0) {
        const currentTab = this.currentTab;
        if (currentTab >= tabs.length) {
          // Handle last tab being removed, so find the last non-disabled tab
          tabIndex = tabs.indexOf(
            tabs
              .slice()
              .reverse()
              .find(notDisabled)
          );
        } else if (tabs[currentTab] && !tabs[currentTab].disabled) {
          // Current tab is not disabled
          tabIndex = currentTab;
        }
      }

      // Else find *first* non-disabled tab in current tabs
      if (tabIndex < 0) {
        tabIndex = tabs.indexOf(tabs.find(notDisabled));
      }

      // Set the current tab state to active
      tabs.forEach(tab => {
        // tab.localActive = idx === tabIndex && !tab.disabled
        tab.localActive = false;
      });
      if (tabs[tabIndex]) {
        tabs[tabIndex].localActive = true;
      }

      // Update the array of tab children
      this.tabs = tabs;
      // Set the currentTab index (can be -1 if no non-disabled tabs)
      this.currentTab = tabIndex;
    },
    // Find a button that controls a tab, given the tab reference
    // Returns the button vm instance
    getButtonForTab(tab) {
      return (this.$refs.buttons || []).find(btn => btn.tab === tab);
    },
    // Force a button to re-render its content, given a <b-tab> instance
    // Called by <b-tab> on `update()`
    updateButton(tab) {
      const button = this.getButtonForTab(tab);
      if (button && button.$forceUpdate) {
        button.$forceUpdate();
      }
    },
    // Activate a tab given a <b-tab> instance
    // Also accessed by <b-tab>
    activateTab(tab) {
      let result = false;
      if (tab) {
        const index = this.tabs.indexOf(tab);
        if (!tab.disabled && index > -1 && index !== this.currentTab) {
          const tabEvt = new NlyEvent("activate-tab", {
            cancelable: true,
            vueTarget: this,
            componentId: this.safeId()
          });
          this.$emit(tabEvt.type, index, this.currentTab, tabEvt);
          if (!tabEvt.defaultPrevented) {
            result = true;
            this.currentTab = index;
          }
        }
      }
      // Couldn't set tab, so ensure v-model is set to `this.currentTab`
      /* istanbul ignore next: should rarely happen */
      if (!result && this.currentTab !== this.value) {
        this.$emit("input", this.currentTab);
      }
      return result;
    },
    // Deactivate a tab given a <b-tab> instance
    // Accessed by <b-tab>
    deactivateTab(tab) {
      if (tab) {
        // Find first non-disabled tab that isn't the one being deactivated
        // If no tabs are available, then don't deactivate current tab
        return this.activateTab(
          this.tabs.filter(t => t !== tab).find(notDisabled)
        );
      }
      /* istanbul ignore next: should never/rarely happen */
      return false;
    },
    // Focus a tab button given its <b-tab> instance
    focusButton(tab) {
      // Wrap in `$nextTick()` to ensure DOM has completed rendering/updating before focusing
      this.$nextTick(() => {
        const button = this.getButtonForTab(tab);
        if (button && button.focus) {
          button.focus();
        }
      });
    },
    // Emit a click event on a specified <b-tab> component instance
    emitTabClick(tab, evt) {
      if (isEvent(evt) && tab && tab.$emit && !tab.disabled) {
        tab.$emit("click", evt);
      }
    },
    // Click handler
    clickTab(tab, evt) {
      this.activateTab(tab);
      this.emitTabClick(tab, evt);
    },
    // Move to first non-disabled tab
    firstTab(focus) {
      const tab = this.tabs.find(notDisabled);
      if (this.activateTab(tab) && focus) {
        this.focusButton(tab);
        this.emitTabClick(tab, focus);
      }
    },
    // Move to previous non-disabled tab
    previousTab(focus) {
      const currentIndex = mathMax(this.currentTab, 0);
      const tab = this.tabs
        .slice(0, currentIndex)
        .reverse()
        .find(notDisabled);
      if (this.activateTab(tab) && focus) {
        this.focusButton(tab);
        this.emitTabClick(tab, focus);
      }
    },
    // Move to next non-disabled tab
    nextTab(focus) {
      const currentIndex = mathMax(this.currentTab, -1);
      const tab = this.tabs.slice(currentIndex + 1).find(notDisabled);
      if (this.activateTab(tab) && focus) {
        this.focusButton(tab);
        this.emitTabClick(tab, focus);
      }
    },
    // Move to last non-disabled tab
    lastTab(focus) {
      const tab = this.tabs
        .slice()
        .reverse()
        .find(notDisabled);
      if (this.activateTab(tab) && focus) {
        this.focusButton(tab);
        this.emitTabClick(tab, focus);
      }
    }
  },
  render(h) {
    const tabs = this.tabs;

    // Currently active tab
    const activeTab = tabs.find(tab => tab.localActive && !tab.disabled);

    // Tab button to allow focusing when no active tab found (keynav only)
    const fallbackTab = tabs.find(tab => !tab.disabled);

    // For each <b-tab> found create the tab buttons
    const buttons = tabs.map((tab, index) => {
      let tabIndex = null;
      // Ensure at least one tab button is focusable when keynav enabled (if possible)
      if (!this.noKeyNav) {
        // Buttons are not in tab index unless active, or a fallback tab
        tabIndex = -1;
        if (activeTab === tab || (!activeTab && fallbackTab === tab)) {
          // Place tab button in tab sequence
          tabIndex = null;
        }
      }
      return h(BTabButtonHelper, {
        key: tab._uid || index,
        ref: "buttons",
        // Needed to make `this.$refs.buttons` an array
        refInFor: true,
        props: {
          tab: tab,
          tabs: tabs,
          id:
            tab.controlledBy ||
            (tab.safeId ? tab.safeId(`_NLYA_tab_button_`) : null),
          controls: tab.safeId ? tab.safeId() : null,
          tabIndex,
          setSize: tabs.length,
          posInSet: index + 1,
          noKeyNav: this.noKeyNav
        },
        on: {
          click: evt => {
            this.clickTab(tab, evt);
          },
          first: this.firstTab,
          prev: this.previousTab,
          next: this.nextTab,
          last: this.lastTab
        }
      });
    });

    // Nav
    let nav = h(
      NlyNav,
      {
        ref: "nav",
        class: this.localNavClass,
        attrs: {
          role: "tablist",
          id: this.safeId("_NLYA_tab_controls_")
        },
        props: {
          fill: this.fill,
          justified: this.justified,
          align: this.align,
          tabs: !this.noNavStyle && !this.pills,
          pills: !this.noNavStyle && this.pills,
          vertical: this.vertical,
          small: this.small,
          cardHeader: this.card && !this.vertical
        }
      },
      [
        this.normalizeSlot("tabs-start") || h(),
        buttons,
        this.normalizeSlot("tabs-end") || h()
      ]
    );
    nav = h(
      "div",
      {
        key: "nlya-tabs-nav",
        class: [
          {
            "card-header": this.card && !this.vertical && !this.end,
            "card-footer": this.card && !this.vertical && this.end,
            "col-auto": this.vertical
          },
          this.navWrapperClass
        ]
      },
      [nav]
    );

    let empty = h();
    if (!tabs || tabs.length === 0) {
      empty = h(
        "div",
        {
          key: "nlya-empty-tab",
          class: ["tab-pane", "active", { "card-body": this.card }]
        },
        this.normalizeSlot("empty")
      );
    }

    // Main content section
    const content = h(
      "div",
      {
        ref: "tabsContainer",
        key: "nly-tabs-container",
        staticClass: "tab-content",
        class: [{ col: this.vertical }, this.contentClass],
        attrs: { id: this.safeId("_NLY_tab_container_") }
      },
      concat(this.normalizeSlot("default"), empty)
    );

    // Render final output
    return h(
      this.tag,
      {
        staticClass: "tabs",
        class: {
          row: this.vertical,
          "no-gutters": this.vertical && this.card
        },
        attrs: { id: this.safeId() }
      },
      [this.end ? content : h(), [nav], this.end ? h() : content]
    );
  }
});
